今天,我們要來搞懂 Redis 的兩個資料結構:String
和 Hash
。
但我們不會像百科全書一樣列出所有指令,而是專注於搶票場景中的實際應用。
在搶票場景中,我們的主要需求是:對同一份資料進行高頻率的原子遞減操作。
String 資料結構完美地滿足了這個需求,因為:
原子性保證:所有 String 的數值操作都是原子的。
回傳值:操作後直接回傳新值,無需額外查詢。
高效能:記憶體操作,微秒級延遲。
簡單性:沒有複雜的資料結構,沒有特殊情況。
讓我們看看如何用 String 來實現票券庫存管理:
# 初始化票券庫存
SET ticket:42 1000
# 原子性扣減庫存
DECRBY ticket:42 1 # 回傳: 999
DECRBY ticket:42 5 # 回傳: 994
# 原子性增加庫存(退票場景)
INCRBY ticket:42 2 # 回傳: 996
# 查詢當前庫存
GET ticket:42 # 回傳: "996"
遵循清晰的命名規範,有助於維護與擴展。
# 票券庫存
ticket:{id} # 例如: ticket:42
# 使用者購買記錄
user:{id}:purchases # 例如: user:12345:purchases
# 活動統計
event:{id}:stats # 例如: event:2024-spring:stats
命名原則:
使用冒號 :
分隔層級,模擬命名空間。
保持簡潔,避免冗長的描述。
便於管理、分片和擴展。
在實際的搶票系統中,一個物件通常擁有多個屬性,例如票券不僅有庫存,還有價格、狀態等。
庫存數量 (quantity)
價格 (price)
狀態 (status)
活動 ID (event_id)
如果用多個 String 來存儲這些資訊:
# 糟糕的設計:多個 String
SET ticket:42:quantity 1000
SET ticket:42:price 2999
SET ticket:42:status active
這會帶來以下問題:
操作複雜性:更新一個票券需要多個命令。
原子性問題:無法保證所有欄位同時更新,可能導致資料不一致。
查詢效率:需要多次網路來回才能獲取完整資訊。
鍵管理困難:大量的鍵佔用 Redis 的鍵空間。
Hash 資料結構將一個物件的多個屬性聚合在單一鍵中,完美地解決了這些問題:
# 優雅的設計:單一 Hash
HSET ticket:42 quantity 1000 price 2999 status active event_id 2024-spring
# 原子性更新單一欄位(例如:庫存)
HINCRBY ticket:42 quantity -1 # 回傳: 999
# 更新多個欄位
HSET ticket:42 status sold updated_at "2024-09-26T10:00:00Z"
# 查詢單一欄位
HGET ticket:42 quantity # 回傳: "999"
# 查詢所有欄位
HGETALL ticket:42 # 回傳所有欄位與值
# 創建票券,包含庫存、價格與狀態
HSET ticket:42 quantity 1000 price 2999 status active
# 購買票券(原子性操作)
remaining=$(redis-cli HINCRBY ticket:42 quantity -1)
if [ $remaining -ge 0 ]; then
# 如果回傳值大於或等於 0,代表搶票成功。
echo "購買成功,剩餘庫存: $remaining"
else
# 如果回傳值是負數,代表庫存不足,搶票失敗
echo "庫存不足,購買失敗"
fi
# 查詢票券狀態
redis-cli HGET ticket:42 status # 回傳: "active"
redis-cli HGET ticket:42 quantity # 回傳: "999"
# 批量更新多個欄位
redis-cli HSET ticket:42 status sold updated_at "2024-09-26T12:00:00Z"
# 批量查詢多個欄位
redis-cli HMGET ticket:42 quantity status price
HINCRBY
同樣是原子的,可以對 Hash 中的單一欄位進行原子操作,而不影響其他欄位。
# HINCRBY 是原子的,不會被其他命令打斷
HINCRBY ticket:42 quantity -1
當 Hash 中的欄位數量不多時,Redis 會使用 ziplist
(或 listpack
) 進行內部編碼,這種儲存方式比多個獨立的 String 鍵更節省記憶體。
單一數值:只需要儲存一個數值(如庫存、計數器)。
高頻原子操作:業務核心是高併發的計數。
簡單邏輯:業務邏輯簡單,不涉及複雜的物件屬性。
# 適合用 String 的場景
SET ticket:42:stock 1000
DECRBY ticket:42:stock 1
多屬性物件:需要將一個物件的多個相關屬性組織在一起。
減少鍵數量:希望將相關資料聚合,簡化鍵空間管理。
查詢效率:需要頻繁查詢物件的多個屬性。
# 適合用 Hash 的場景
HSET ticket:42:info quantity 1000 price 2999 status active
HINCRBY ticket:42:info quantity -1
HGETALL ticket:42:info
在實際的搶票系統中,混合使用是常見且高效的策略:
# 用 String 儲存高頻操作的庫存,鍵更短,語義更清晰
SET ticket:42:stock 1000
# 用 Hash 儲存票券的詳細資訊(較少變動)
HSET ticket:42:info price 2999 status active event_id 2024-spring
# 高頻操作使用 String
DECRBY ticket:42:stock 1
# 詳細查詢使用 Hash
HGETALL ticket:42:info
這種設計把 動靜資料分離,高頻的寫操作(庫存)和相對靜態的讀操作(票券資訊)分開,雖然優化了高頻寫入的性能,但也帶來一個小小的代價:
當你需要同時獲取票券的『庫存』和『價格』時,就需要發起兩個 Redis 命令
(GET ticket:42:stock
和 HGET ticket:42:info price
),而不是單一的 HMGET
或 HGETALL
。
用讀取複雜性換取寫入性能的取捨。
技術挑戰 (Technical Challenge) | 商業風險 (Business Risk) | 煉金術 (Go Alchemy) | 商業價值 (Business Value) |
---|---|---|---|
競爭條件 (Race Condition) | 庫存超賣,直接造成財務虧損 | sync.Mutex , atomic |
庫存精準,確保利潤,建立信任 |
效能瓶頸 (Performance Bottleneck) | 響應緩慢,用戶流失,轉換率低 | goroutine , Worker Pool |
高吞吐量,提升用戶體驗,最大化訂單量 |
單點故障 (Single Point of Failure) | 服務中斷,活動失敗,品牌受損 | 訊息隊列 (MQ), 熔斷降級 | 高可用性,保障業務連續性,控制風險 |
對非數字字串執行數值操作會導致錯誤。
# 錯誤
SET ticket:42 "not_a_number"
DECRBY ticket:42 1 # 錯誤:(error) ERR value is not an integer or out of range
解決方案:在應用程式層確保寫入 Redis 的值類型正確。
對高頻存取的 Key(如庫存)設定過期時間是危險的。
一旦 Key 過期,可能導致庫存資料丟失,引發超賣。
# 危險操作
SET ticket:42:stock 1000
EXPIRE ticket:42:stock 3600 # 風險:一小時後庫存資料會消失
解決方案:主要業務資料(如庫存)通常不設定過期時間,其生命週期由業務邏輯管理。
回到最初的問題:String 和 Hash 哪個更好?答案是:看場景。
追求極致的單點效能,用 String 做計數器。 需要聚合管理物件屬性,用 Hash 做結構化存儲。
想在頂尖系統中平衡效能與維護性,採用 動靜分離的混合模式。